/*
 * Copyright (C) 2023 Huawei Device Co., Ltd.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

//@ts-nocheck
import createHmac from '../js/crypto';
import { EventEmitter } from '../js/events';
import socket from '@ohos.net.socket';
import connection from '@ohos.net.connection';
import { SMTPError, SMTPErrorStates } from './error.js';
import { SMTPResponseMonitor } from './response.js';
import to from './await-to-js';
import SocketMsg from './SocketMsg'
import buffer from '@ohos.buffer';

/**
 * @readonly
* @enum
 */
export const AUTH_METHODS = {
  PLAIN: 'PLAIN',
  'CRAM-MD5': 'CRAM-MD5',
  LOGIN: 'LOGIN',
  XOAUTH2: 'XOAUTH2',
} as const;

/**
 * @readonly
* @enum
 */
export const SMTPState = {
  NOTCONNECTED: 0,
  CONNECTING: 1,
  CONNECTED: 2,
} as const;

export const DEFAULT_TIMEOUT = 5000 as const;

const SMTP_PORT = 25 as const;
const SMTP_SSL_PORT = 465 as const;
const SMTP_TLS_PORT = 587 as const;
const CRLF = '\r\n' as const;
const GREYLIST_DELAY = 300 as const;

let DEBUG: 0 | 1 = 0;

/**
 * @param {...any[]} args the message(s) to log
 * @returns {void}
 */
const log = (...args: any[]) => {
  if (DEBUG === 1) {
    args.forEach((d) =>
    console.log(
        typeof d === 'object'
        ? d instanceof Error
            ? d.message
            : JSON.stringify(d)
        : d
    )
    );
  }
};

/**
 * @param {function(...any[]): void} callback the function to call
 * @param {...any[]} args the arguments to apply to the function
 * @returns {void}
 */
const caller = (callback?: (...rest: any[]) => void, ...args: any[]) => {
  if (typeof callback === 'function') {
    callback(...args);
  }
};

export type SMTPSocketOptions = Omit<socket.TCPConnectOptions | socket.TLSConnectOptions,
'port' | 'host' | 'path' | 'socket' | 'timeout' | 'secureContext'>;

export interface SMTPConnectionOptions {
  timeout: number | null;
  user: string;
  password: string;
  domain: string;
  host: string;
  port: number;
  ssl: boolean | SMTPSocketOptions;
  tls: boolean | SMTPSocketOptions;
  authentication: (keyof typeof AUTH_METHODS)[];
  logger: (...args: any[]) => void;
}

export interface ConnectOptions {
  ssl?: boolean;
}

export class SMTPConnection extends EventEmitter {
  public readonly user: () => string;
  public readonly password: () => string;
  public readonly timeout: number = DEFAULT_TIMEOUT;

  protected readonly log = log;
  protected readonly authentication: (keyof typeof AUTH_METHODS)[] = [
  AUTH_METHODS['CRAM-MD5'],
  AUTH_METHODS.LOGIN,
  AUTH_METHODS.PLAIN,
  AUTH_METHODS.XOAUTH2,
  ];

  protected _state: 0 | 1 | 2 = SMTPState.NOTCONNECTED;
  protected _secure = false;
  protected loggedin = false;

  protected sock: socket.TCPSocket | socket.TLSSocket | null = null;
  protected features: { [index: string]: string | boolean } | null = null;
  protected monitor: SMTPResponseMonitor | null = null;
  protected domain = undefined;
  protected host = 'localhost';
  protected ssl: boolean | SMTPSocketOptions = false;
  protected tls: boolean | SMTPSocketOptions = false;
  protected port: number;
  private msg = new SocketMsg()
  private greylistResponseTracker = new WeakSet<(...rest: any[]) => void>();

  /**
   * SMTP class written using python's (2.7) smtplib.py as a base.
   *
   * To target a Message Transfer Agent (MTA), omit all options.
   *
   * NOTE: `host` is trimmed before being used to establish a connection; however, the original untrimmed value will still be visible in configuration.
   */
  constructor({
                timeout,
                host,
                user,
                password,
                domain,
                port,
                ssl,
                tls,
                logger,
                authentication,
              }: Partial<SMTPConnectionOptions> = {}) {
    super();
    const ctx = this;
    if (Array.isArray(authentication)) {
      this.authentication = authentication;
    }

    if (typeof timeout === 'number') {
      this.timeout = timeout;
    }
    if (typeof domain === 'string') {
      ctx.domain = domain;
    }
    if (!domain || domain.length < 1) {
      ctx.getHostName().then((result) => {
        ctx.domain = result;
      }).catch((err) => {
        ctx.domain = undefined;
      })
    }
    if (typeof host === 'string') {
      this.host = host;
    }

    if (
      ssl != null &&
      (typeof ssl === 'boolean' ||
      (typeof ssl === 'object' && Array.isArray(ssl) === false))
    ) {
      this.ssl = ssl;
    }

    if (
      tls != null &&
      (typeof tls === 'boolean' ||
      (typeof tls === 'object' && Array.isArray(tls) === false))
    ) {
      this.tls = tls;
    }

    this.port = port || (ssl ? SMTP_SSL_PORT : tls ? SMTP_TLS_PORT : SMTP_PORT);
    this.loggedin = user && password ? false : true;

    if (!user && (password?.length ?? 0) > 0) {
      throw new Error('`password` cannot be set without `user`');
    }

    // keep these strings hidden when quicky debugging/logging
    this.user = () => user as string;
    this.password = () => password as string;

    if (typeof logger === 'function') {
      this.log = logger;
    }
  }

  private async getHostName(): Promise<string> {
    let [netHandleErr, netHandle] = await to<connection.NetHandle>(connection.getDefaultNet())
    if (netHandleErr) {
      return new Promise(function (resolve, reject) {
        reject(netHandleErr)
      })
    }
    if (!netHandle) {
      return new Promise(function (resolve, reject) {
        reject(new Error('get Default Net is fail'))
      })
    }
    let [netSyncErr, netSyncInfo] = await to<connection.ConnectionProperties>(connection.getConnectionProperties(netHandle))
    if (netSyncErr) {
      return new Promise(function (resolve, reject) {
        reject(netSyncErr)
      })
    }
    if (!netSyncInfo || !netSyncInfo.linkAddresses || netSyncInfo.linkAddresses.length < 1
    || !netSyncInfo.linkAddresses[0] || !netSyncInfo.linkAddresses[0].address) {
      return new Promise(function (resolve, reject) {
        reject(new Error('get local ip is fail'))
      })
    }
    return new Promise(function (resolve, reject) {
      resolve(netSyncInfo.linkAddresses[0].address.address)
    })
  }

  /**
   * @public
  * @param {0 | 1} level -
   * @returns {void}
   */
  public debug(level: 0 | 1) {
    DEBUG = level;
  }

  /**
   * @public
  * @returns {SMTPState} the current state
   */
  public state() {
    return this._state;
  }

  /**
   * @public
  * @returns {boolean} whether or not the instance is authorized
   */
  public authorized() {
    return this.loggedin;
  }

  /**
   * Establish an SMTP connection.
   *
   * NOTE: `host` is trimmed before being used to establish a connection; however, the original untrimmed value will still be visible in configuration.
   *
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @param {number} [port] the port to use for the connection
   * @param {string} [host] the hostname to use for the connection
   * @param {ConnectOptions} [options={}] the options
   * @returns {void}
   */
  public async connect(
    callback: (...rest: any[]) => void,
    port: number = this.port,
    host: string = this.host,
    options: ConnectOptions = {}
  ) {
    this.port = port;
    this.host = host;
    this.ssl = options.ssl || this.ssl;

    if (this._state !== SMTPState.NOTCONNECTED) {
      this.quit(() => this.connect(callback, port, host, options));
    }

    /**
     * @returns {void}
     */
    const connected = () => {
      this.log(`connected: ${this.host}:${this.port}`);

      if (this.ssl && !this.tls) {
        // if key/ca/cert was passed in, check if connection is authorized
        let isTls = 'getCipherSuite' in this.sock
        if (
          typeof this.ssl !== 'boolean' && isTls /*&& !this.sock.authorized*/
        ) {
          this.close(true);
          caller(callback, SMTPError.create('could not establish an ssl connection', SMTPErrorStates.CONNECTIONAUTH));
        } else {
          this._secure = true;
          callback(); // TODO 原库不存在，因为不回调自行添加 需审视
        }
      }
    };

    /**
     * @param {Error} err err
     * @returns {void}
     */
    const connectedErrBack = async (err?: Error) => {
      if (!err) {
        connected();
      } else {
        await this.close(true);
        this.log(err);
        caller(
          callback,
          SMTPError.create(
            'could not connect',
            SMTPErrorStates.COULDNOTCONNECT,
            err
          )
        );
      }
    };

    const response = (
      err: Error | null | undefined,
      msg: {
        code: string | number;
        data: string
      }
    ) => {
      if (err) {
        if (this._state === SMTPState.NOTCONNECTED && !this.sock) {
          return;
        }
        this.close(true);
        caller(callback, err);
      } else if (msg.code == '220') {
        this.log(msg.data);

        // might happen first, so no need to wait on connected()
        this._state = SMTPState.CONNECTED;
        caller(callback, null, msg.data);
      } else {
        this.log(`response (data): ${msg.data}`);
        this.quit(() => {
          caller(
            callback,
            SMTPError.create(
              'bad response on connection',
              SMTPErrorStates.BADRESPONSE,
              err,
              msg.data
            )
          );
        });
      }
    };

    this._state = SMTPState.CONNECTING;
    this.log(`connecting: ${this.host}:${this.port}`);
    let [netHandleErr, netHandle] = await to<connection.NetHandle>(connection.getDefaultNet())
    if (netHandleErr) {
      return new Promise(function (resolve, reject) {
        reject(netHandleErr)
      })
    }
    if (!netHandle) {
      return new Promise(function (resolve, reject) {
        reject(new Error('get Default Net is fail'))
      })
    }
    let [netSyncErr, netSyncInfo] = await to<connection.ConnectionProperties>(connection.getConnectionProperties(netHandle))
    if (netSyncErr) {
      return new Promise(function (resolve, reject) {
        reject(netSyncErr)
      })
    }
    if (!netSyncInfo || !netSyncInfo.linkAddresses || netSyncInfo.linkAddresses.length < 1
    || !netSyncInfo.linkAddresses[0] || !netSyncInfo.linkAddresses[0].address) {
      return new Promise(function (resolve, reject) {
        reject(new Error('get local ip is fail'))
      })
    }
    if (this.ssl) {

      this.sock = socket.constructTLSSocketInstance()
      let [bindErr, bindInfo] = await to<void>(this.sock.bind({
        address: netSyncInfo.linkAddresses[0].address.address,
        port: netSyncInfo.linkAddresses[0].address.port,
        family: netSyncInfo.linkAddresses[0].address.family
      }))
      if (bindErr) {
        connectedErrBack(bindErr);
        return;
      }
      let option = this.ssl as socket.TLSConnectOptions;
      option.address.port = this.port;
      option.address.address = this.host.trim();

      this.monitor = new SMTPResponseMonitor(this.sock, this.timeout, this.msg, () => this.close(true));
      this.msg.once('response', response);
      this.sock.on('error', async (err) => {
        if (err) {
          if (this._state === SMTPState.NOTCONNECTED && !this.sock) {
            return
          }
          await this.close(true);
          caller(callback, err)
        }
      }); // the socket could reset or throw, so let's handle it and let the user know


      let [addressErr, addressInfo] = await to<Array<socket.NetAddress>>(connection.getAddressesByName(option.address.address))
      if (addressErr) {
        connectedErrBack(addressErr);
        return;
      }
      if (!addressInfo || addressInfo.length < 1) {
        connectedErrBack(new Error('parse host fail'));
        return;
      }
      let parseHost = addressInfo[0].address
      let parsePort = addressInfo[0].port;
      for (let i = 0; i < addressInfo.length; i++) {
        if (!addressInfo[i]) {
          continue;
        }
        if (addressInfo[i].family === 2 || addressInfo[i].address.indexOf("::") != -1) {
          continue;
        }
        parseHost = addressInfo[i].address;
        parsePort = addressInfo[i].port;
        break;
      }

      option.address.port = port ? port : parsePort;
      option.address.address = parseHost;

      let [connectErr, connectInfo] = await to<void>(this.sock.connect(option))
      if (connectErr) {
        connectedErrBack(connectErr);
        return;
      }
      connected()
    } else {
      // this.sock = new Socket();
      // this.sock.connect(this.port, this.host.trim(), connectedErrBack);
      this.sock = socket.constructTCPSocketInstance()
      let [bindErr, bindInfo] = await to<void>(this.sock.bind({
        address: netSyncInfo.linkAddresses[0].address.address,
        port: netSyncInfo.linkAddresses[0].address.port,
        family: netSyncInfo.linkAddresses[0].address.family
      }))
      if (bindErr) {
        connectedErrBack(bindErr);
        return;
      }
      this.monitor = new SMTPResponseMonitor(this.sock, this.timeout, this.msg, () => this.close(true));
      this.msg.once('response', response);
      this.sock.on('error', async (err) => {
        if (err) {
          if (this._state === SMTPState.NOTCONNECTED && !this.sock) {
            return
          }
          await this.close(true);
          caller(callback, err)
        }
      }); // the socket could reset or throw, so let's handle it and let the user know

      let option: socket.TCPConnectOptions = undefined;
      if (typeof this.ssl === 'object') {
        option = this.ssl as socket.TCPConnectOptions;
      } else {
        option = {
          address: {
            address: this.host.trim(),
            port: this.port,
            family: netSyncInfo.linkAddresses[0].address.family
          },
          timeout: this.timeout ? this.timeout : 30000
        };
      }
      option.address.port = port ? port : this.port;
      option.address.address = host ? host : this.host.trim();


      let [addressErr, addressInfo] = await to<Array<socket.NetAddress>>(connection.getAddressesByName(option.address.address))
      if (addressErr) {
        connectedErrBack(addressErr);
        return;
      }
      if (!addressInfo || addressInfo.length < 1) {
        connectedErrBack(new Error('parse host fail'));
        return;
      }
      let parseHost = addressInfo[0].address
      let parsePort = addressInfo[0].port;
      for (let i = 0; i < addressInfo.length; i++) {
        if (!addressInfo[i]) {
          continue;
        }
        if (addressInfo[i].family === 2 || addressInfo[i].address.indexOf("::") != -1) {
          continue;
        }
        parseHost = addressInfo[i].address;
        parsePort = addressInfo[i].port;
        break;
      }

      option.address.port = port ? port : parsePort;
      option.address.address = parseHost;
      let [connectErr, connectInfo] = await to<void>(this.sock.connect(option))
      if (connectErr) {
        connectedErrBack(connectErr);
        return;
      }
      connected()
    }
  }

  /**
   * @public
  * @param {string} str the string to send
   * @param {function(...any[]): void} callback function to call after response
   * @returns {void}
   */
  public send(str: string, callback: (...args: any[]) => void) {
    const ctx = this;
    if (this.sock != null && this._state === SMTPState.CONNECTED) {
      this.log(str);
      ctx.msg.once('response', (err, msg) => {
        if (err) {
          caller(callback, err);
        } else {
          this.log(msg.data);
          caller(callback, null, msg);
        }
      });
      if ('getCipherSuite' in this.sock) {
        let tempSocket = this.sock as socket.TLSSocket;
        tempSocket.send(str)
      } else {
        let tempSocket = this.sock as socket.TCPSocket;
        tempSocket.send({
          data: str
        })
      }
      // if (this.sock.writable) {
      //   this.sock.write(str);
      // }
    } else {
      this.close(true);
      caller(
        callback,
        SMTPError.create(
          'no connection has been established',
          SMTPErrorStates.NOCONNECTION
        )
      );
    }
  }

  /**
   * @public
  * @param {string} cmd command to issue
   * @param {function(...any[]): void} callback function to call after response
   * @param {(number[] | number)} [codes=[250]] array codes
   * @returns {void}
   */
  public command(
    cmd: string,
    callback: (...rest: any[]) => void,
    codes: number[] | number = [250]
  ) {
    const codesArray = Array.isArray(codes)
      ? codes
      : typeof codes === 'number'
          ? [codes]
          : [250];

    const response = (
      err: Error | null | undefined,
      msg: {
        code: string | number;
        data: string;
        message: string
      }
    ) => {
      if (err) {
        caller(callback, err);
      } else {
        const code = Number(msg.code);
        if (codesArray.indexOf(code) !== -1) {
          caller(callback, err, msg.data, msg.message);
        } else if (
          (code === 450 || code === 451) &&
          msg.message.toLowerCase().includes('greylist') &&
          this.greylistResponseTracker.has(response) === false
        ) {
          this.greylistResponseTracker.add(response);
          setTimeout(() => {
            this.send(cmd + CRLF, response);
          }, GREYLIST_DELAY);
        } else {
          const suffix = msg.message ? `: ${msg.message}` : '';
          const errorMessage = `bad response on command '${
          cmd.split(' ')[0]
          }'${suffix}`;
          caller(
            callback,
            SMTPError.create(
              errorMessage,
              SMTPErrorStates.BADRESPONSE,
              null,
              msg.data
            )
          );
        }
      }
    };

    this.greylistResponseTracker.delete(response);
    this.send(cmd + CRLF, response);
  }

  /**
   * @public
  * @description SMTP 'helo' command.
   *
   * Hostname to send for self command defaults to the FQDN of the local
   * host.
   *
   * As this command was deprecated by rfc2821, it should only be used for compatibility with non-compliant servers.
   * @see https://tools.ietf.org/html/rfc2821#appendix-F.3
   *
   * @param {function(...any[]): void} callback function to call after response
   * @param {string} domain the domain to associate with the 'helo' request
   * @returns {void}
   */
  public helo(callback: (...rest: any[]) => void, domain?: string) {
    this.command(`helo ${domain || this.domain}`, (err, data) => {
      if (err) {
        caller(callback, err);
      } else {
        this.parse_smtp_features(data);
        caller(callback, err, data);
      }
    });
  }

  /**
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @returns {void}
   */
  public starttls(callback: (...rest: any[]) => void) {
    // const response = (err: Error, msg: { data: unknown }) => {
    //   if (this.sock == null) {
    //     throw new Error('null socket');
    //   }
    //
    //   if (err) {
    //     err.message += ' while establishing a starttls session';
    //     caller(callback, err);
    //   } else {
    //     const secureContext = createSecureContext(
    //         typeof this.tls === 'object' ? this.tls : {}
    //     );
    //     const secureSocket = new TLSSocket(this.sock, { secureContext });
    //
    //     secureSocket.on('error', (err: Error) => {
    //       this.close(true);
    //       caller(callback, err);
    //     });
    //
    //     this._secure = true;
    //     this.sock = secureSocket;
    //
    //     new SMTPResponseMonitor(this.sock, this.timeout, () =>
    //     this.close(true)
    //     );
    //     caller(callback, msg.data);
    //   }
    // };
    //
    // this.command('starttls', response, [220]);
  }

  /**
   * @public
  * @param {string} data the string to parse for features
   * @returns {void}
   */
  public parse_smtp_features(data: string) {
    //  According to RFC1869 some (badly written)
    //  MTA's will disconnect on an ehlo. Toss an exception if
    //  that happens -ddm

    data.split('\n').forEach((ext) => {
      const parse = ext.match(/^(?:\d+[-=]?)\s*?([^\s]+)(?:\s+(.*)\s*?)?$/);

      // To be able to communicate with as many SMTP servers as possible,
      // we have to take the old-style auth advertisement into account,
      // because:
      // 1) Else our SMTP feature parser gets confused.
      // 2) There are some servers that only advertise the auth methods we
      // support using the old style.

      if (parse != null && this.features != null) {
        // RFC 1869 requires a space between ehlo keyword and parameters.
        // It's actually stricter, in that only spaces are allowed between
        // parameters, but were not going to check for that here.  Note
        // that the space isn't present if there are no parameters.
        this.features[parse[1].toLowerCase()] = parse[2] || true;
      }
    });
  }

  /**
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @param {string} domain the domain to associate with the 'ehlo' request
   * @returns {void}
   */
  public ehlo(callback: (...rest: any[]) => void, domain?: string) {
    this.features = {};
    this.command(`ehlo ${domain || this.domain}`, (err, data) => {
      if (err) {
        caller(callback, err);
      } else {
        this.parse_smtp_features(data);

        if (this.tls && !this._secure) {
          this.starttls(() => this.ehlo(callback, domain));
        } else {
          caller(callback, err, data);
        }
      }
    });
  }

  /**
   * @public
  * @param {string} opt the features keyname to check
   * @returns {boolean} whether the extension exists
   */
  public has_extn(opt: string) {
    return (this.features ?? {})[opt.toLowerCase()] === undefined;
  }

  /**
   * @public
  * @description SMTP 'help' command, returns text from the server
   * @param {function(...any[]): void} callback function to call after response
   * @param {string} domain the domain to associate with the 'help' request
   * @returns {void}
   */
  public help(callback: (...rest: any[]) => void, domain: string) {
    this.command(domain ? `help ${domain}` : 'help', callback, [211, 214]);
  }

  /**
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @returns {void}
   */
  public rset(callback: (...rest: any[]) => void) {
    this.command('rset', callback);
  }

  /**
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @returns {void}
   */
  public noop(callback: (...rest: any[]) => void) {
    this.send('noop', callback);
  }

  /**
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @param {string} from the sender
   * @returns {void}
   */
  public mail(callback: (...rest: any[]) => void, from: string) {
    this.command(`mail FROM:${from}`, callback);
  }

  /**
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @param {string} to the receiver
   * @returns {void}
   */
  public rcpt(callback: (...rest: any[]) => void, to: string) {
    this.command(`RCPT TO:${to}`, callback, [250, 251]);
  }

  /**
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @returns {void}
   */
  public data(callback: (...rest: any[]) => void) {
    this.command('data', callback, [354]);
  }

  /**
   * @public
  * @param {function(...any[]): void} callback function to call after response
   * @returns {void}
   */
  public data_end(callback: (...rest: any[]) => void) {
    this.command(`${CRLF}.`, callback);
  }

  /**
   * @public
  * @param {string} data the message to send
   * @returns {void}
   */
  public message(data: string) {
    this.log(data);
    if (!this.sock) {
      this.log('no socket to write to');
      return;
    }
    if ('getCipherSuite' in this.sock) {
      let tempSocket = this.sock as socket.TLSSocket;
      tempSocket.send(data)
    } else {
      let tempSocket = this.sock as socket.TCPSocket;
      tempSocket.send({
        data: data
      })
    }
  }

  /**
   * @public
  * @description SMTP 'verify' command -- checks for address validity.
   * @param {string} address the address to validate
   * @param {function(...any[]): void} callback function to call after response
   * @returns {void}
   */
  public verify(address: string, callback: (...rest: any[]) => void) {
    this.command(`vrfy ${address}`, callback, [250, 251, 252]);
  }

  /**
   * @public
  * @description SMTP 'expn' command -- expands a mailing list.
   * @param {string} address the mailing list to expand
   * @param {function(...any[]): void} callback function to call after response
   * @returns {void}
   */
  public expn(address: string, callback: (...rest: any[]) => void) {
    this.command(`expn ${address}`, callback);
  }

  /**
   * @public
  * @description Calls this.ehlo() and, if an error occurs, this.helo().
   *
   * If there has been no previous EHLO or HELO command self session, self
   * method tries ESMTP EHLO first.
   *
   * @param {function(...any[]): void} callback function to call after response
   * @param {string} [domain] the domain to associate with the command
   * @returns {void}
   */
  public ehlo_or_helo_if_needed(
    callback: (...rest: any[]) => void,
    domain?: string
  ) {
    // is this code callable...?
    if (!this.features) {
      const response = (err: Error, data: unknown) =>
      caller(callback, err, data);
      this.ehlo((err, data) => {
        if (err) {
          this.helo(response, domain);
        } else {
          caller(callback, err, data);
        }
      }, domain);
    }
  }

  /**
   * @public
  *
   * Log in on an SMTP server that requires authentication.
   *
   * If there has been no previous EHLO or HELO command self session, self
   * method tries ESMTP EHLO first.
   *
   * This method will return normally if the authentication was successful.
   *
   * @param {function(...any[]): void} callback function to call after response
   * @param {string} [user] the username to authenticate with
   * @param {string} [password] the password for the authentication
   * @param {{ method: string, domain: string }} [options] login options
   * @returns {void}
   */
  public login(
    callback: (...rest: any[]) => void,
    user?: string,
    password?: string,
    options: {
      method?: string;
      domain?: string
    } = {}
  ) {
    const login = {
      user: user ? () => user : this.user,
      password: password ? () => password : this.password,
      method: options?.method?.toUpperCase() ?? '',
    };

    const domain = options?.domain || this.domain;

    const initiate = (err: Error | null | undefined, data: unknown) => {
      if (err) {
        caller(callback, err);
        return;
      }

      let method: keyof typeof AUTH_METHODS | null = null;

      /**
       * @param {string} challenge challenge
       * @returns {string} base64 cram hash
       */
      const encodeCramMd5 = (challenge: string) => {
        const hmac = createHmac('md5', login.password());
        hmac.update(buffer.from(challenge, 'base64').toString('ascii'));
        return buffer.from(`${login.user()} ${hmac.digest('hex')}`).toString(
          'base64'
        );
      };

      /**
       * @returns {string} base64 login/password
       */
      const encodePlain = () =>
      buffer.from(`\u0000${login.user()}\u0000${login.password()}`).toString(
        'base64'
      );

      /**
       * @see https://developers.google.com/gmail/xoauth2_protocol
       * @returns {string} base64 xoauth2 auth token
       */
      const encodeXoauth2 = () =>
      buffer.from(
        `user=${login.user()}\u0001auth=Bearer ${login.password()}\u0001\u0001`
      ).toString('base64');

      // List of authentication methods we support: from preferred to
      // less preferred methods.
      if (!method) {
        const preferred = this.authentication;
        let auth = '';

        if (typeof this.features?.['auth'] === 'string') {
          auth = this.features['auth'];
        }

        for (let i = 0; i < preferred.length; i++) {
          if (auth.includes(preferred[i])) {
            method = preferred[i];
            break;
          }
        }
      }

      /**
       * handle bad responses from command differently
       * @param {Error} err err
       * @param {unknown} data data
       * @returns {void}
       */
      const failed = (err: Error, data: unknown) => {
        this.loggedin = false;
        this.close(); // if auth is bad, close the connection, it won't get better by itself

        err.message = err.message.replace(this.password(), 'REDACTED');

        caller(
          callback,
          SMTPError.create(
            'authorization.failed',
            SMTPErrorStates.AUTHFAILED,
            err,
            data
          )
        );
      };

      /**
       * @param {Error} err err
       * @param {unknown} data data
       * @returns {void}
       */
      const response = (err: Error | null | undefined, data: unknown) => {
        if (err) {
          failed(err, data);
        } else {
          this.loggedin = true;
          caller(callback, err, data);
        }
      };

      /**
       * @param {Error} err err
       * @param {unknown} data data
       * @param {string} msg msg
       * @returns {void}
       */
      const attempt = (
        err: Error | null | undefined,
        data: unknown,
        msg: string
      ) => {
        if (err) {
          failed(err, data);
        } else {
          if (method === AUTH_METHODS['CRAM-MD5']) {
            this.command(encodeCramMd5(msg), response, [235, 503]);
          } else if (method === AUTH_METHODS.LOGIN) {
            this.command(
              buffer.from(login.password()).toString('base64'),
              response,
              [235, 503]
            );
          }
        }
      };

      /**
       * @param {Error} err err
       * @param {unknown} data data
       * @param {string} msg msg
       * @returns {void}
       */
      const attemptUser = (err: Error, data: unknown) => {
        if (err) {
          failed(err, data);
        } else {
          if (method === AUTH_METHODS.LOGIN) {
            this.command(
              buffer.from(login.user()).toString('base64'),
              attempt,
              [334]
            );
          }
        }
      };

      switch (method) {
        case AUTH_METHODS['CRAM-MD5']:
          this.command(`AUTH  ${AUTH_METHODS['CRAM-MD5']}`, attempt, [334]);
          break;
        case AUTH_METHODS.LOGIN:
          this.command(`AUTH ${AUTH_METHODS.LOGIN}`, attemptUser, [334]);
          break;
        case AUTH_METHODS.PLAIN:
          this.command(
            `AUTH ${AUTH_METHODS.PLAIN} ${encodePlain()}`,
            response,
            [235, 503]
          );
          break;
        case AUTH_METHODS.XOAUTH2:
          this.command(
            `AUTH ${AUTH_METHODS.XOAUTH2} ${encodeXoauth2()}`,
            response,
            [235, 503]
          );
          break;
        default:
          caller(
            callback,
            SMTPError.create(
              'no form of authorization supported',
              SMTPErrorStates.AUTHNOTSUPPORTED,
              null,
              data
            )
          );
          break;
      }
    };

    this.ehlo_or_helo_if_needed(initiate, domain);
  }

  /**
   * @public
  * @param {boolean} [force=false] whether or not to force destroy the connection
   * @returns {void}
   */
  public async close(force = false) {
    try {
      if (this.sock) {
        this.sock.off('connect')
        this.sock.off('message')
        this.sock.off('err')
        if (force) {
          this.log('smtp connection destroyed!');
          await this.sock.close();
        } else {
          this.log('smtp connection closed.');
          await this.sock.close();
        }
      }
      this.sock.off('close')
      if (this.monitor) {
        // this.monitor.stop(); 源库逻辑 socket关闭之后再移除socket的监听会报错 这里调整执行顺序 本处的方法注释
        this.monitor = null;
      }

      this._state = SMTPState.NOTCONNECTED;
      this._secure = false;
      this.sock = null;
      this.features = null;
      this.loggedin = !(this.user() && this.password());
    } catch (err) {
      this._state = SMTPState.NOTCONNECTED;
      this._secure = false;
      this.sock = null;
      this.features = null;
      this.loggedin = !(this.user() && this.password());
    }
  }

  /**
   * @public
  * @param {function(...any[]): void} [callback] function to call after response
   * @returns {void}
   */
  public quit(callback?: (...rest: any[]) => void) {
    this.command(
      'quit',
      (err, data) => {
        caller(callback, err, data);
        this.close();
      },
      [221, 250]
    );
  }
}
